TypeScript 55.4%
Python 43.2%
SQL 1.2%
1import Link from "next/link";2import type { Metadata } from "next";3import { notFound } from "next/navigation";4import { PRIORITY_LABELS } from "@websensor/core/client";5import { EventRow } from "@/components/event-row";6import { FieldChangeInline } from "@/components/field-changes";7import { Chip, Empty, ExtLink, HealthPill, PageHeader, Panel, Stat, Table, Td, TierBadge } from "@/components/ui";8import { api, type SnapshotRow } from "@/lib/api";9import { CLASS_LABELS, dayHeader, fmtBytes, fmtDuration, fmtInt, fmtMs, relTime, shortHash, untilTime, utcDate, utcDateTime, utcTime } from "@/lib/format";1011export const dynamic = "force-dynamic";1213export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {14 const { id } = await params;15 const d = await api.sensor(id);16 return { title: d ? `${d.sensor.source_name} · ${d.sensor.name} — sensor` : "Sensor not found", robots: { index: false } };17}1819/** Sensor page with historical memory (spec §79): how this page looked over time. */20export default async function SensorPage({ params }: { params: Promise<{ id: string }> }) {21 const { id } = await params;22 const d = await api.sensor(id);23 if (!d) notFound();24 const s = d.sensor;25 const snaps = await api.sensorSnapshots(s.id, 200);26 const history = snaps.items.length ? snaps.items : d.snapshots;27 const runs = s.total_runs ?? 0;28 const checks24 = s.checks_24h ?? 0;29 const nm24 = checks24 ? Math.round(((s.not_modified_24h ?? 0) / checks24) * 100) : null;30 const nmAll = runs ? Math.round(((s.total_not_modified ?? 0) / runs) * 100) : null;31 const events = d.events ?? [];32 const prio = s.priority ?? null;33 // Group snapshots by UTC day (newest first, API order).34 const days: { day: string; rows: SnapshotRow[] }[] = [];35 for (const sn of history) {36 const day = utcDate(sn.captured_at);37 const last = days[days.length - 1];38 if (last && last.day === day) last.rows.push(sn);39 else days.push({ day, rows: [sn] });40 }41 const changeCount = history.filter((x) => x.has_change).length;42 return (43 <>44 <PageHeader45 compact46 kicker={47 <span className="flex flex-wrap items-center gap-2">48 <Link href={`/source/${s.source_id}`} className="hover:underline">{s.source_name}</Link>49 <span className="text-fg-subtle">/</span>50 <span className="font-mono">{s.type}</span>51 <span className="text-fg-subtle">·</span>52 <span className="font-mono text-fg-muted">{s.connector}</span>53 <TierBadge tier={s.tier} />54 {prio !== null && <Chip tone={prio === 0 ? "hot" : prio === 1 ? "high" : "default"} className="font-mono" title={PRIORITY_LABELS[prio] ?? `Priority ${prio}`}>P{prio}</Chip>}55 {s.status && <HealthPill health={s.enabled ? s.status : "DISABLED"} />}56 <HealthPill health={s.enabled ? s.health : "DISABLED"} />57 </span>58 }59 title={s.name}60 description={<ExtLink href={s.url} className="font-mono text-[12px] break-all">{s.url}</ExtLink>}61 actions={62 <>63 <Link href={`/url?u=${encodeURIComponent(s.url)}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">URL history →</Link>64 {s.domain && <Link href={`/domain/${s.domain}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Domain →</Link>}65 </>66 }67 />6869 <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-4 lg:grid-cols-8 lg:divide-y-0">70 <Stat label="Checks · 24 h" value={fmtInt(checks24)} hint={`${fmtInt(runs)} total`} />71 <Stat label="304 · 24 h" value={nm24 !== null ? `${nm24}%` : "—"} hint={nmAll !== null ? `${nmAll}% all-time` : s.has_etag || s.has_last_modified ? "validators exposed" : "no validators"} />72 <Stat label="Errors · 24 h" value={fmtInt(s.errors_24h ?? 0)} tone={(s.errors_24h ?? 0) > 0 ? "warn" : undefined} hint={s.consecutive_errors ? <span className="text-danger">{s.consecutive_errors} consecutive</span> : "0 consecutive"} />73 <Stat label="Avg latency" value={fmtMs(s.avg_ms_24h ?? s.avg_latency_ms)} hint={s.avg_ms_24h !== null && s.avg_ms_24h !== undefined ? "last 24 h" : "all-time"} />74 <Stat label="Raw / meaningful" value={<span>{fmtInt(s.raw_changes)}<span className="text-fg-subtle"> / </span><span className="text-signal">{fmtInt(s.meaningful_changes)}</span></span>} hint={s.raw_changes ? `noise ${Math.round((1 - (s.meaningful_changes ?? 0) / Math.max(1, s.raw_changes)) * 100)}%` : undefined} />75 <Stat label="Snapshots" value={fmtInt(history.length)} hint={`${changeCount} with a change`} />76 <Stat label="Last check" value={s.last_check_at ? relTime(s.last_check_at) : "never"} hint={s.last_status ? `HTTP ${s.last_status}` : undefined} />77 <Stat label="Next check" value={untilTime(s.next_check_at)} hint={s.current_interval_seconds ? `every ${fmtDuration(s.current_interval_seconds)}` : s.base_interval_seconds ? `base ${fmtDuration(s.base_interval_seconds)}` : "adaptive"} />78 </div>79 {s.last_error && (80 <div className="mb-4 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 font-mono text-[12px] text-danger break-words">81 last error · {s.last_error}82 </div>83 )}8485 <div className="grid gap-4 xl:grid-cols-[1fr_380px]">86 <div className="flex min-w-0 flex-col gap-4">87 <Panel title={`How this page looked over time · ${history.length} snapshot${history.length === 1 ? "" : "s"}`} dense action={<span className="hidden text-[11px] text-fg-subtle sm:inline">raw bodies may be pruned by retention · canonical form and hashes are kept</span>}>88 {history.length === 0 ? (89 <Empty>No snapshot yet — the first successful check stores the initial state of this page.</Empty>90 ) : (91 <div className="overflow-x-auto">92 <table className="w-full text-[12.5px]">93 <thead>94 <tr className="border-b border-line text-left">95 {["Captured", "HTTP", "Size", "Canonical hash", "Change", "Event", "Raw", "Compare"].map((h) => (96 <th key={h} className="label whitespace-nowrap px-3 py-2 font-semibold">{h}</th>97 ))}98 </tr>99 </thead>100 {days.map((g) => (101 <tbody key={g.day} className="divide-y divide-line border-b border-line">102 <tr className="bg-panel-2/50">103 <td colSpan={8} className="px-3 py-1 font-mono text-[10.5px] font-semibold tracking-wider text-fg-subtle">{dayHeader(g.rows[0]!.captured_at)} <span className="font-normal">· {g.rows.length} capture{g.rows.length === 1 ? "" : "s"}</span></td>104 </tr>105 {g.rows.map((sn) => {106 const idx = history.indexOf(sn);107 const prev = history[idx + 1];108 return (109 <tr key={sn.id} className={`hover:bg-panel-2/60 ${sn.has_change ? "border-l-2 border-l-signal/60" : "border-l-2 border-l-transparent"}`}>110 <Td mono className="whitespace-nowrap"><span title={utcDateTime(sn.captured_at)}>{utcTime(sn.captured_at)}</span> <span className="text-fg-subtle">{relTime(sn.captured_at)}</span></Td>111 <Td mono className={sn.http_status && sn.http_status >= 400 ? "text-warn" : "text-fg-muted"}>{sn.http_status ?? "—"}</Td>112 <Td mono className="whitespace-nowrap">{fmtBytes(sn.content_length)}</Td>113 <Td mono className="text-fg-subtle"><span title={sn.canonical_hash ?? undefined}>{shortHash(sn.canonical_hash)}</span>{sn.mode ? <span className="ml-1.5 text-[10.5px]">{sn.mode}</span> : null}</Td>114 <Td>{sn.has_change ? <Chip tone="signal" className="font-mono">CHANGED</Chip> : <span className="text-fg-subtle">—</span>}</Td>115 <Td>{sn.event_slug ? <Link href={`/event/${sn.event_slug}`} className="text-info hover:underline">event →</Link> : <span className="text-fg-subtle">—</span>}</Td>116 <Td>117 {sn.has_raw === false ? (118 <span className="cursor-not-allowed text-fg-subtle line-through" title="raw body pruned by retention">view raw</span>119 ) : (120 <a href={`/api/v1/snapshots/${sn.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="text-info hover:underline">view raw</a>121 )}122 </Td>123 <Td>{prev ? <Link href={`/compare?a=${prev.id}&b=${sn.id}`} className="whitespace-nowrap text-info hover:underline">compare with previous</Link> : <span className="text-fg-subtle">first capture</span>}</Td>124 </tr>125 );126 })}127 </tbody>128 ))}129 </table>130 </div>131 )}132 </Panel>133134 <Panel title={`Changes · ${d.changes.length}`} dense>135 {d.changes.length ? (136 <Table head={["Detected", "Kind", "Class", "Signal", "Meaningful", "What changed", "Links"]}>137 {d.changes.map((c) => (138 <tr key={c.id} className="hover:bg-panel-2/60">139 <Td mono className="whitespace-nowrap text-fg-subtle"><span title={utcDateTime(c.detected_at)}>{utcDate(c.detected_at)} {utcTime(c.detected_at, false)}</span></Td>140 <Td mono>{c.kind}</Td>141 <Td>{c.change_class ? <Chip tone={c.change_class === "pricing" || c.change_class === "policy" ? "high" : c.change_class === "meaningful" || c.change_class === "product" || c.change_class === "personnel" ? "signal" : "default"}>{CLASS_LABELS[c.change_class] ?? c.change_class}</Chip> : c.heuristic_type ? <Chip>{c.heuristic_type.replace(/_/g, " ")}</Chip> : <span className="text-fg-subtle">—</span>}</Td>142 <Td mono className={c.signal >= 0.32 ? "text-signal" : "text-fg-subtle"}>{c.signal.toFixed(2)}{c.noise_ratio !== undefined && c.noise_ratio > 0 ? <span className="ml-1 text-[10.5px] text-fg-subtle">noise {Math.round(c.noise_ratio * 100)}%</span> : null}</Td>143 <Td>{c.meaningful ? <Chip tone="ok" className="font-mono">YES</Chip> : <span className="font-mono text-[11px] text-fg-subtle">filtered</span>}</Td>144 <Td className="min-w-[14rem]">{c.field_changes?.length ? <FieldChangeInline items={c.field_changes} max={2} /> : c.magnitude !== undefined && c.magnitude !== null ? <span className="font-mono text-[11px] text-fg-subtle">magnitude {c.magnitude}</span> : <span className="text-fg-subtle">—</span>}</Td>145 <Td className="whitespace-nowrap">146 {c.event_id && <Link href={`/event/${c.event_id}`} className="text-info hover:underline">event</Link>}147 {c.event_id && c.old_snapshot_id && c.new_snapshot_id && <span className="text-fg-subtle"> · </span>}148 {c.old_snapshot_id && c.new_snapshot_id && <Link href={`/compare?a=${c.old_snapshot_id}&b=${c.new_snapshot_id}`} className="text-info hover:underline">diff</Link>}149 {!c.event_id && !(c.old_snapshot_id && c.new_snapshot_id) && <span className="text-fg-subtle">—</span>}150 </Td>151 </tr>152 ))}153 </Table>154 ) : (155 <Empty>No change detected yet.</Empty>156 )}157 </Panel>158159 <Panel title={`Runs · ${d.runs.length}`} dense>160 {d.runs.length ? (161 <Table head={["Started", "Outcome", "HTTP", "Duration", "Bytes", "Method", "Error"]}>162 {d.runs.map((r) => (163 <tr key={r.id} className="hover:bg-panel-2/60">164 <Td mono className="whitespace-nowrap text-fg-subtle">{utcDateTime(r.started_at)}</Td>165 <Td><Chip tone={r.outcome === "event" ? "signal" : r.outcome === "changed" ? "info" : r.outcome === "error" || r.outcome === "parse_error" ? "danger" : r.outcome === "rate_limited" || r.outcome === "missing" ? "warn" : "default"}>{r.outcome}</Chip></Td>166 <Td mono>{r.http_status ?? "—"}</Td>167 <Td mono>{fmtMs(r.duration_ms)}</Td>168 <Td mono>{fmtBytes(r.bytes)}</Td>169 <Td mono className="text-fg-subtle">{r.fetch_method ?? "—"}</Td>170 <Td className="max-w-[24rem] truncate text-danger" >{r.error ?? ""}</Td>171 </tr>172 ))}173 </Table>174 ) : (175 <Empty>No run recorded in the retained window.</Empty>176 )}177 </Panel>178 </div>179180 <aside className="flex min-w-0 flex-col gap-4">181 <Panel title={`Events · ${events.length}`} dense action={<Link href={`/source/${s.source_id}`} className="text-[11px] text-fg-subtle hover:text-fg">source →</Link>}>182 {events.length ? events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No meaningful event from this sensor yet — raw changes that are noise never become events.</Empty>}183 </Panel>184 <Panel title="Conditional requests">185 <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]">186 <dt className="text-fg-subtle">ETag</dt><dd className="min-w-0 truncate font-mono text-[11.5px]" title={s.etag ?? undefined}>{s.etag ?? <span className="text-fg-subtle">not exposed</span>}</dd>187 <dt className="text-fg-subtle">Last-Modified</dt><dd className="min-w-0 truncate font-mono text-[11.5px]" title={s.last_modified ?? undefined}>{s.last_modified ?? <span className="text-fg-subtle">not exposed</span>}</dd>188 <dt className="text-fg-subtle">Last change</dt><dd className="font-mono text-[11.5px]">{s.last_change_at ? relTime(s.last_change_at) : "—"}</dd>189 <dt className="text-fg-subtle">Last event</dt><dd className="font-mono text-[11.5px]">{s.last_event_at ? relTime(s.last_event_at) : "—"}</dd>190 <dt className="text-fg-subtle">Validated</dt><dd className="font-mono text-[11.5px]">{s.validated_at ? relTime(s.validated_at) : "—"}</dd>191 </dl>192 </Panel>193 </aside>194 </div>195 </>196 );197}198